상태를 가까이 두는 것이 좋은 이유
상태를 가까이 두는 것이 좋은 이유
상태를 가까이 둔다는 것은 무조건 leaf component에 넣는다는 뜻이 아니다. 그 값을 읽고 변경하는 컴포넌트들의 가장 가까운 공통 소유자에 두고, 필요 범위가 넓어질 때만 위로 올린다는 뜻이다. 상태의 종류에 따라 UI state는 컴포넌트, 공유 선택은 공통 조상, URL로 복원해야 할 값은 router, 서버 데이터는 server cache가 소유하는 편이 자연스럽다.
목차
- #모든 상태를 전역에 두면 편해 보이는 이유
- #상태의 소유자는 읽기와 변경 범위로 찾는다
- #로컬 상태가 결합을 줄이는 방식
- #공유가 필요할 때 가장 가까운 공통 조상으로 올리기
- #상태를 너무 높이 올렸을 때의 렌더 범위
- #상태의 종류에 따라 소유 장소가 달라진다
- #중복 상태와 파생 상태를 제거하기
- #상태 수명은 컴포넌트 트리 위치로 표현한다
- #Context와 외부 store로 승격하는 기준
- #상태를 다시 아래로 내리는 리팩터링
- #테스트와 리뷰 체크리스트
- #정리
- #관련 노트와 참고 자료
모든 상태를 전역에 두면 편해 보이는 이유
애플리케이션이 커지면 “언젠가 다른 화면에서도 쓸 수 있으니”라는 이유로 state를 전역 store에 넣기 쉽다.
type AppState = {
currentUser: User | null;
theme: Theme;
productSearchQuery: string;
productSort: SortOption;
createModalOpen: boolean;
editingProductId: string | null;
checkoutMemo: string;
hoveredRowId: string | null;
};
어디서든 읽고 변경할 수 있어 prop 전달이 줄고, 상태가 한 파일에 모여 찾기 쉬워 보인다. 하지만 시간이 지나면 서로 다른 수명의 값이 하나의 store에 남는다.
- 페이지를 벗어나도 이전 검색어가 남는다.
- modal을 닫았다 열었는데 편집 중이던 값이 복원된다.
- 다른 테스트에서 전역 store 상태가 새어 들어온다.
- 작은 hover 변경이 넓은 구독 범위를 갱신한다.
- 어떤 화면이 값을 소유하고 초기화해야 하는지 알기 어렵다.
상태가 전역이라는 말은 단순히 접근이 편하다는 뜻이 아니다. 애플리케이션 수명 동안 존재하고 여러 소비자가 변경 가능하며, 초기화와 동기화 규칙을 별도로 가져야 한다는 의미가 된다.
실제로 공유하는 소비자가 생기기 전까지는 가장 좁은 범위에서 시작한다. state를 위로 올리는 것은 쉽지만 전역 의존성을 다시 분리하는 일은 더 어렵다.
상태의 소유자는 읽기와 변경 범위로 찾는다
상태 위치를 정할 때 다음 질문을 순서대로 묻는다.
- 이 값이 없으면 렌더할 수 없는 컴포넌트는 어디인가?
- 이 값을 변경하는 사용자 상호작용은 어디에서 일어나는가?
- 값이 언제 초기화되고 언제 사라져야 하는가?
- 새로고침·뒤로가기·공유 URL 이후에도 보존해야 하는가?
- 서버가 원본 데이터의 소유자인가?
예를 들어 상품 카드의 “상세 설명 펼치기”는 카드 하나만 읽고 변경한다.
function ProductCard({ product }: { product: Product }) {
const [expanded, setExpanded] = useState(false);
return (
<article>
<h2>{product.name}</h2>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
>
{expanded ? "설명 접기" : "설명 펼치기"}
</button>
{expanded && <p>{product.description}</p>}
</article>
);
}
다른 카드가 이 값을 알 필요가 없고 카드가 사라질 때 state도 사라져야 한다. 가장 가까운 소유자는 ProductCard다.
반대로 Accordion에서 한 panel만 열려야 한다면 panel들이 같은 선택을 공유해야 한다.
flowchart TB
A[Accordion
openPanelId 소유] --> B[Panel A]
A --> C[Panel B]
A --> D[Panel C]이 state의 가장 가까운 공통 소유자는 Accordion이다.
로컬 상태가 결합을 줄이는 방식
로컬 state는 구현 세부사항을 컴포넌트 안에 가둔다.
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
async function handleCopy() {
await navigator.clipboard.writeText(text);
setCopied(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setCopied(false);
}, 1500);
}
return (
<button type="button" onClick={handleCopy}>
{copied ? "복사됨" : "복사"}
</button>
);
}
부모는 copied 상태와 timer를 알 필요가 없다. CopyButton을 다른 화면에 옮겨도 별도 store 설정이 필요 없다. 두 개를 렌더하면 각각 독립적으로 동작한다.
이것이 캡슐화다.
- 변경 원인이 state 선언 가까이에 있다.
- unmount가 자연스러운 초기화가 된다.
- 컴포넌트를 테스트할 때 전역 store를 준비하지 않는다.
- 동일 컴포넌트 여러 인스턴스가 서로 간섭하지 않는다.
- 영향받는 렌더 subtree가 작아진다.
로컬 상태가 많다는 사실 자체가 설계 문제는 아니다. 서로 독립적인 UI 기억이 각각의 소유자 안에 있는 것은 자연스럽다.
공유가 필요할 때 가장 가까운 공통 조상으로 올리기
각 Panel이 isOpen을 따로 가지면 여러 panel이 동시에 열릴 수 있다.
function Panel({ title, children }: PropsWithChildren<{ title: string }>) {
const [open, setOpen] = useState(false);
return (
<section>
<button onClick={() => setOpen((current) => !current)}>
{title}
</button>
{open && children}
</section>
);
}
요구사항이 “항상 하나만 열기”로 바뀌면 두 local state를 Effect로 동기화하려 하지 않는다. state를 제거하고 공통 부모에 하나의 source of truth를 둔다.
type AccordionItem = {
id: string;
title: string;
content: ReactNode;
};
function Accordion({ items }: { items: AccordionItem[] }) {
const [openId, setOpenId] = useState<string | null>(null);
return (
<div>
{items.map((item) => (
<Panel
key={item.id}
title={item.title}
open={openId === item.id}
onToggle={() =>
setOpenId((current) =>
current === item.id ? null : item.id,
)
}
>
{item.content}
</Panel>
))}
</div>
);
}
function Panel({
title,
open,
onToggle,
children,
}: PanelProps) {
return (
<section>
<button
type="button"
aria-expanded={open}
onClick={onToggle}
>
{title}
</button>
{open && children}
</section>
);
}
Panel은 controlled component가 되고 Accordion이 정책을 소유한다. state를 올렸지만 앱 root까지 올리지 않고 실제 공유하는 가장 가까운 조상까지만 이동했다.
상태를 너무 높이 올렸을 때의 렌더 범위
상품 검색 입력 state를 페이지 root에 두면 키 입력마다 큰 subtree가 렌더될 수 있다.
function Dashboard() {
const [query, setQuery] = useState("");
return (
<>
<ApplicationHeader />
<AnalyticsChart />
<ProductSearch query={query} onQueryChange={setQuery} />
<RecentOrders />
</>
);
}
query를 쓰지 않는 Header와 Chart도 부모 render 흐름에 포함된다. React가 DOM 전체를 바꾸지는 않지만 무거운 계산이 있다면 비용이 된다.
검색 상태가 검색 영역에서만 필요하다면 아래로 내린다.
function Dashboard() {
return (
<>
<ApplicationHeader />
<AnalyticsChart />
<ProductSearch />
<RecentOrders />
</>
);
}
function ProductSearch() {
const [query, setQuery] = useState("");
return (
<>
<SearchInput value={query} onChange={setQuery} />
<SearchResult query={query} />
</>
);
}
state locality는 성능 최적화 기법 이전에 데이터 흐름을 정확히 표현한다. 그 결과 렌더 범위도 자연스럽게 작아진다.
그러나 URL에 검색어를 남겨 뒤로가기와 공유 링크를 지원해야 한다면 local state가 source of truth가 되어서는 안 된다. 이 경우 router search params가 더 적합하다.
상태의 종류에 따라 소유 장소가 달라진다
“전역인가 로컬인가” 두 가지로만 분류하면 잘못된 저장소를 고르기 쉽다.
| 상태 종류 | 예 | 자연스러운 소유자 |
|---|---|---|
| 일시적 UI state | tooltip, modal, hover, 펼침 | 가까운 컴포넌트 |
| 공유 UI state | accordion 선택, wizard 단계 | 가장 가까운 공통 조상 |
| form draft | 입력값, touched, validation | form 또는 form library |
| URL state | 검색어, 정렬, 페이지, tab | router/search params |
| 서버 상태 | 상품 목록, 사용자 정보 | query cache/framework data |
| 앱 설정 | theme, locale | 좁은 Context |
| 고빈도 공유 client state | 편집기 selection, canvas | selector 가능한 store |
URL state
필터를 새로고침과 뒤로가기 후에도 보존하고 링크로 공유해야 한다면 URL이 source of truth다.
function ProductFilter() {
const [searchParams, setSearchParams] = useSearchParams();
const sort = searchParams.get("sort") ?? "recommended";
function handleSortChange(nextSort: string) {
setSearchParams((current) => {
const next = new URLSearchParams(current);
next.set("sort", nextSort);
return next;
});
}
return <SortSelect value={sort} onChange={handleSortChange} />;
}
URL 값을 다시 local state로 복사해 Effect로 맞추면 source of truth가 둘이 된다.
서버 상태
API 데이터는 서버가 원본을 소유한다. cache, stale time, refetch, mutation invalidation이 필요하므로 Context state에 복사하기보다 query 도구를 쓴다.
const productsQuery = useQuery({
queryKey: ["products", filters],
queryFn: ({ signal }) => fetchProducts(filters, signal),
});
form state
입력 중인 배송 메모는 제출 전까지 form의 draft다. 다른 route에서 필요하지 않으면 form subtree가 소유한다. 이탈 후 복원 요구가 생기면 localStorage나 서버 draft와 동기화하는 정책을 별도로 설계한다.
값의 타입이 문자열이라는 이유로 같은 store에 두는 것이 아니라 누가 원본을 소유하고 어떤 수명과 복원 의미가 필요한지로 결정한다.
중복 상태와 파생 상태를 제거하기
상태가 많아 보일 때 위치를 옮기기 전에 실제로 state가 필요한지 확인한다.
function ProductTable({ products }: Props) {
const [selectedProduct, setSelectedProduct] =
useState<Product | null>(null);
}
products가 refetch되어 같은 상품의 최신 객체가 들어와도 selectedProduct는 오래된 객체일 수 있다. 선택 ID만 state에 두고 객체는 렌더 중 찾는다.
const [selectedId, setSelectedId] = useState<string | null>(null);
const selectedProduct =
products.find((product) => product.id === selectedId) ?? null;
필터 결과도 중복 state로 두지 않는다.
const [query, setQuery] = useState("");
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
useEffect(() => {
setFilteredProducts(filterProducts(products, query));
}, [products, query]);
렌더 중 계산한다.
const filteredProducts = filterProducts(products, query);
계산이 실제로 비쌀 때만 useMemo를 검토한다. 중복 state를 제거하면 동기화를 위한 Effect와 버그도 함께 사라진다.
불가능한 상태 조합도 줄인다.
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: Product[] }
| { status: "error"; error: Error };
loading, error, data를 각각 독립 state로 두어 loading === true이면서 error가 있는 모순을 만드는 것보다 상태 머신 형태가 의도를 잘 표현한다.
상태 수명은 컴포넌트 트리 위치로 표현한다
React state는 UI 트리 위치와 연결된다. component가 unmount되면 state도 제거된다. 이를 초기화 정책으로 활용할 수 있다.
사용자를 바꿀 때 편집 draft를 초기화하려면 key를 준다.
function ProfilePage({ userId }: { userId: string }) {
return <ProfileEditor key={userId} userId={userId} />;
}
userId가 바뀌면 다른 컴포넌트 정체성으로 판단해 로컬 form state가 초기화된다.
modal을 닫을 때 state를 버릴지 보존할지도 트리 구조로 표현한다.
function ProductPage() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>상품 추가</button>
{open && (
<CreateProductDialog onClose={() => setOpen(false)} />
)}
</>
);
}
조건부 렌더로 unmount되므로 다시 열면 초기 form이다. 닫아도 draft를 유지해야 한다면 state를 부모로 올리거나 dialog를 mount한 채 숨기는 등 명시적인 선택이 필요하다.
무조건 store에 저장해 영구 보존하기 전에 기대 수명을 정한다.
flowchart LR
A[컴포넌트 수명] --> B[페이지 수명]
B --> C[탭 세션 수명]
C --> D[로그인 세션 수명]
D --> E[서버 영속 수명]오른쪽으로 갈수록 초기화, migration, 보안, 동기화 비용이 커진다.
Context와 외부 store로 승격하는 기준
prop drilling이 보인다고 즉시 Context를 만들지는 않는다. 명시적인 props는 데이터 흐름을 보여 주며 몇 단계 정도는 오히려 읽기 쉽다.
Context로 올릴 후보는 다음과 같다.
- 트리의 여러 깊은 위치에서 같은 값을 읽는다.
- 값의 소유 범위가 Provider subtree와 자연스럽게 일치한다.
- 변경 빈도가 과도하게 높지 않다.
- theme, locale, auth dependency처럼 공통 의미가 있다.
외부 store 후보는 다음과 같다.
- 서로 멀리 떨어진 소비자가 매우 많다.
- 소비자별로 작은 slice를 구독해야 한다.
- React 밖 코드도 변경과 구독에 참여한다.
- undo/redo, devtools, persistence 같은 요구가 있다.
- Profiler에서 Context 전파 비용이 실제로 확인된다.
승격 과정은 단계적일 수 있다.
컴포넌트 local state
→ 형제 공유 필요: 공통 조상으로 lift
→ 깊은 subtree 공유: Context
→ 고빈도 선택 구독: external store
→ 복원 가능한 탐색 상태: URL
→ 서버 원본 데이터: query cache
도구를 고르는 것은 성숙도 등급을 올리는 일이 아니다. 각 상태가 요구하는 소유권과 구독 모델을 맞추는 일이다.
상태를 다시 아래로 내리는 리팩터링
이미 전역 store에 있는 modal state를 local로 되돌리는 과정을 보자.
기존 store:
type UiStore = {
createProductOpen: boolean;
openCreateProduct: () => void;
closeCreateProduct: () => void;
};
사용처를 검색해 실제로 한 페이지의 버튼과 dialog만 사용한다면 페이지 안으로 이동한다.
function ProductManagementPage() {
const [createDialogOpen, setCreateDialogOpen] = useState(false);
return (
<>
<button onClick={() => setCreateDialogOpen(true)}>
상품 생성
</button>
{createDialogOpen && (
<CreateProductDialog
onClose={() => setCreateDialogOpen(false)}
/>
)}
</>
);
}
리팩터링할 때 확인할 항목은 다음과 같다.
- store 값을 읽고 쓰는 모든 소비자를 찾는다.
- URL, 단축키, 다른 route 등 숨은 소비자가 없는지 확인한다.
- 새 소유자와 초기화 시점을 정한다.
- persistence가 정말 필요했는지 확인한다.
- 전역 action과 selector를 제거한다.
- 페이지 재진입과 여러 인스턴스 테스트를 추가한다.
전역에서 local로 내리는 것은 기능 축소가 아니라 실제 소유권을 코드에 반영하는 작업이다.
테스트와 리뷰 체크리스트
local state는 인스턴스 격리를 테스트한다.
it("상품 카드의 펼침 상태는 서로 독립적이다", async () => {
const user = userEvent.setup();
render(
<>
<ProductCard product={firstProduct} />
<ProductCard product={secondProduct} />
</>,
);
await user.click(
screen.getAllByRole("button", { name: "설명 펼치기" })[0],
);
expect(screen.getByText(firstProduct.description)).toBeVisible();
expect(screen.queryByText(secondProduct.description)).toBeNull();
});
공유 state는 single source of truth를 테스트한다.
it("accordion에서는 하나의 panel만 열린다", async () => {
const user = userEvent.setup();
render(<Accordion items={items} />);
await user.click(screen.getByRole("button", { name: "배송" }));
await user.click(screen.getByRole("button", { name: "환불" }));
expect(screen.getByText("환불 정책")).toBeVisible();
expect(screen.queryByText("배송 정책")).toBeNull();
});
리뷰할 때 묻는다.
- 이 값은 실제 state인가, props와 다른 state에서 계산 가능한가?
- 누가 읽고 누가 변경하는가?
- 가장 가까운 공통 소유자는 어디인가?
- unmount될 때 사라져도 되는가?
- 뒤로가기와 공유 URL에서 복원되어야 하는가?
- 서버 데이터의 복사본을 만들고 있지 않은가?
- 미래의 가상 소비자 때문에 전역화하지 않았는가?
- 전역 store의 selector 범위가 실제 소비 필드와 맞는가?
정리
상태를 가까이 둔다는 것은 데이터 흐름과 수명을 실제 사용 범위에 맞추는 일이다. 모든 state를 leaf에 고립시키라는 뜻도, 공유 state를 금지한다는 뜻도 아니다.
- 한 컴포넌트만 사용하는 UI state는 local로 둔다.
- 형제가 함께 바뀌어야 하면 가장 가까운 공통 조상으로 올린다.
- 깊은 subtree dependency는 책임별 Context를 고려한다.
- URL, 서버 cache, form draft를 같은 전역 state로 취급하지 않는다.
- 파생 값과 중복 객체를 state에서 제거한다.
- 컴포넌트 위치와 key로 보존·초기화 수명을 표현한다.
- 실제 구독 요구가 생길 때만 external store로 승격한다.
각 state에는 하나의 명확한 소유자가 있어야 한다. 그 소유자는 앱에서 가장 높은 곳이 아니라 값을 함께 읽고 변경해야 하는 가장 가까운 곳이다.